NexusPi Git Node
Commit 786bddfe0aebae27a59e4e8aa7a87ae9a1163c12
Parents : ea3d524
Author : Ivan <e318cbc04468bd574db2b4523dddd710>
Signature : T66BB85Valid, signed by author
Date : 2026-08-21T01:45:00-05:00
feat: improve websocket broadcasting and update CSP policies to improve security and performance
Changes
9 files changed, 69 insertions(+), 45 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 555aba0d..aae4204c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,7 +13,8 @@ All notable changes to this project will be documented in this file.
- **Docker (too many open files)**: Announce-thread SQLite handles close when the thread exits. RNS ratchet writes share one worker. Websocket reconnects close the previous socket. Finished RNS websocket clients drop their sockets. Log rollover writes to stderr when the log file cannot reopen.
- **Messages**: Conversation list and thread load keep working while announces arrive. A closed database handle reopens instead of returning HTTP 500.
-- **UI WebSocket**: `/ws` returns 503 after 64 clients.
+- **UI WebSocket**: `/ws` returns 503 after 64 clients. Ready-status broadcasts JSON-encode the payload so aiohttp send_str does not reject a dict.
+- **CSP**: Drop invalid `ws://[::1]:*` connect-src entries. Permissions-Policy is set without a duplicate Feature-Policy header.
## [4.8.4] - 2026-08-20 [released]
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 2a757cbb..f3cf3920 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 3d1ff06e..f54d8ca3 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -1862,12 +1862,14 @@ class ReticulumMeshChat:
try:
AsyncUtils.run_async(
self.websocket_broadcast(
- {
- "type": "startup_status",
- "status": "ok",
- "stage": "ready",
- "network_ready": True,
- },
+ json.dumps(
+ {
+ "type": "startup_status",
+ "status": "ok",
+ "stage": "ready",
+ "network_ready": True,
+ },
+ ),
),
)
except Exception:
@@ -1879,15 +1881,17 @@ class ReticulumMeshChat:
try:
AsyncUtils.run_async(
self.websocket_broadcast(
- {
- "type": "startup_status",
- "status": "failed",
- "stage": "failed",
- "network_ready": False,
- "network_degraded": True,
- "ui_ready": True,
- "error": str(exc),
- },
+ json.dumps(
+ {
+ "type": "startup_status",
+ "status": "failed",
+ "stage": "failed",
+ "network_ready": False,
+ "network_degraded": True,
+ "ui_ready": True,
+ "error": str(exc),
+ },
+ ),
),
)
except Exception:
@@ -7165,6 +7169,10 @@ class ReticulumMeshChat:
plugin_manager.on_rns_link_event(payload)
async def websocket_broadcast(self, data):
+ if isinstance(data, (dict, list)):
+ data = json.dumps(data)
+ elif not isinstance(data, str):
+ data = json.dumps(data)
# Serialize: concurrent callers must not interleave. The second snapshot must run
# only after the first broadcast has finished mutating the live client list.
sessions_changed = False
diff --git a/meshchatx/src/backend/http/middleware.py b/meshchatx/src/backend/http/middleware.py
index 93d15fc7..a4cc7724 100644
--- a/meshchatx/src/backend/http/middleware.py
+++ b/meshchatx/src/backend/http/middleware.py
@@ -289,28 +289,24 @@ def create_security_middleware(app):
# Explicitly allow mic/camera, autoplay, speaker routing, and hardware
# transports for this origin. Listing only mic/camera without bluetooth
# /serial/usb has caused some Chromium and Brave builds to treat
- # hardware APIs as unavailable. Feature-Policy is the legacy name still
- # read by older Chromium-based Brave builds.
- permissions_policy = (
+ # hardware APIs as unavailable. Do not also send the legacy feature
+ # policy header. Chromium ignores unrecognized tokens there and warns
+ # when the same features appear on both headers.
+ response.headers["Permissions-Policy"] = (
"microphone=(self), camera=(self), autoplay=(self), speaker-selection=(self), "
"bluetooth=(self), serial=(self), usb=(self)"
)
- response.headers["Permissions-Policy"] = permissions_policy
- response.headers["Feature-Policy"] = (
- "microphone 'self'; camera 'self'; autoplay 'self'; speaker-selection 'self'; "
- "bluetooth 'self'; serial 'self'; usb 'self'"
- )
# CSP base configuration
privacy_mode = privacy_mode_enabled(app.config)
+ # IPv6 loopback with a port wildcard is not a valid CSP source
+ # (ws://[::1]:* is ignored). Same-origin WS is covered by 'self'.
connect_sources = [
"'self'",
"ws://localhost:*",
"wss://localhost:*",
"ws://127.0.0.1:*",
"wss://127.0.0.1:*",
- "ws://[::1]:*",
- "wss://[::1]:*",
"blob:",
]
img_sources = [
diff --git a/meshchatx/src/backend/lifecycle/deferred_network.py b/meshchatx/src/backend/lifecycle/deferred_network.py
index 4e12544f..973e6a9b 100644
--- a/meshchatx/src/backend/lifecycle/deferred_network.py
+++ b/meshchatx/src/backend/lifecycle/deferred_network.py
@@ -8,6 +8,7 @@ identity and RNS lifecycle stay reviewable outside meshchat.py.
from __future__ import annotations
+import json
import traceback
from meshchatx.src.backend.async_utils import AsyncUtils
@@ -33,12 +34,14 @@ def run_network_setup(app) -> None:
try:
AsyncUtils.run_async(
app.websocket_broadcast(
- {
- "type": "startup_status",
- "status": "ok",
- "stage": "ready",
- "network_ready": True,
- },
+ json.dumps(
+ {
+ "type": "startup_status",
+ "status": "ok",
+ "stage": "ready",
+ "network_ready": True,
+ },
+ ),
),
)
except Exception:
@@ -50,15 +53,17 @@ def run_network_setup(app) -> None:
try:
AsyncUtils.run_async(
app.websocket_broadcast(
- {
- "type": "startup_status",
- "status": "failed",
- "stage": "failed",
- "network_ready": False,
- "network_degraded": True,
- "ui_ready": True,
- "error": str(exc),
- },
+ json.dumps(
+ {
+ "type": "startup_status",
+ "status": "failed",
+ "stage": "failed",
+ "network_ready": False,
+ "network_degraded": True,
+ "ui_ready": True,
+ "error": str(exc),
+ },
+ ),
),
)
except Exception:
diff --git a/tests/backend/test_csp_logic.py b/tests/backend/test_csp_logic.py
index 0e1ed37c..03b9736f 100644
--- a/tests/backend/test_csp_logic.py
+++ b/tests/backend/test_csp_logic.py
@@ -69,12 +69,14 @@ async def test_csp_header_logic(mock_rns_minimal, tmp_path):
assert "default-src 'self'" in csp
assert "wasm-unsafe-eval" in csp
assert "wss://127.0.0.1:*" in csp
+ assert "ws://[::1]" not in csp
+ assert "wss://[::1]" not in csp
assert (
response.headers.get("Permissions-Policy")
== "microphone=(self), camera=(self), autoplay=(self), speaker-selection=(self), "
"bluetooth=(self), serial=(self), usb=(self)"
)
- assert "microphone 'self'" in (response.headers.get("Feature-Policy") or "")
+ assert "Feature-Policy" not in response.headers
m = re.search(r"script-src([^;]+);", csp)
assert m is not None and "blob:" in m.group(1)
script_src = m.group(1)
diff --git a/tests/backend/test_websocket_scale.py b/tests/backend/test_websocket_scale.py
index 7487f571..4c54a273 100644
--- a/tests/backend/test_websocket_scale.py
+++ b/tests/backend/test_websocket_scale.py
@@ -5,6 +5,7 @@
from __future__ import annotations
import asyncio
+import json
from unittest.mock import AsyncMock
import pytest
@@ -37,6 +38,18 @@ async def test_websocket_broadcast_fanout_many_clients(mock_app):
assert c.send_str.await_args[0][0] == payload
+@pytest.mark.asyncio
+async def test_websocket_broadcast_json_dumps_dict_payload(mock_app):
+ mock_app.websocket_clients.clear()
+ client = MagicWs()
+ mock_app.websocket_clients.append(client)
+ real = _bind_real_websocket_broadcast(mock_app)
+ await real({"type": "startup_status", "status": "ok", "stage": "ready"})
+ raw = client.send_str.await_args[0][0]
+ assert isinstance(raw, str)
+ assert json.loads(raw)["type"] == "startup_status"
+
+
@pytest.mark.asyncio
async def test_websocket_broadcast_concurrent_broadcasts(mock_app):
mock_app.websocket_clients.clear()
diff --git a/tests/e2e/browser-permissions.spec.js b/tests/e2e/browser-permissions.spec.js
index c005b72f..3fd84b4f 100644
--- a/tests/e2e/browser-permissions.spec.js
+++ b/tests/e2e/browser-permissions.spec.js
@@ -216,7 +216,6 @@ test.describe("RNode flasher Web Bluetooth chooser path", () => {
expect(policy).toContain("speaker-selection=(self)");
expect(policy).toContain("serial=(self)");
expect(policy).toContain("usb=(self)");
- const featurePolicy = index.headers()["feature-policy"] || "";
- expect(featurePolicy).toContain("microphone 'self'");
+ expect(index.headers()["feature-policy"] || "").toBe("");
});
});
diff --git a/tests/frontend/localeTheme.adversarial.test.js b/tests/frontend/localeTheme.adversarial.test.js
index 9920360e..ddfec7e6 100644
--- a/tests/frontend/localeTheme.adversarial.test.js
+++ b/tests/frontend/localeTheme.adversarial.test.js
@@ -151,7 +151,7 @@ describe("localeTheme adversarial / fuzz", () => {
expect(mw).toContain("Permissions-Policy");
expect(mw).toContain("microphone=(self)");
expect(mw).toContain("autoplay=(self)");
- expect(mw).toContain("Feature-Policy");
+ expect(mw).not.toContain('["Feature-Policy"]');
expect(mw).toContain("bluetooth=(self)");
expect(mw).toContain("serial=(self)");
expect(mw).toContain("usb=(self)");
Served by rngit 1.5.2 - Generated in 0.07s